package org.dftproject.familysearch.core;

import static java.lang.String.format;
import static java.lang.System.err;
import static java.lang.System.out;
import static org.dftproject.familysearch.core.Constants.LOGIN_SITE;
import static org.dftproject.familysearch.core.Constants.PORT;
import static org.dftproject.familysearch.core.Constants.SEARCH_SITE;
import static org.dftproject.familysearch.core.Constants.SERVER;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.xml.bind.JAXBContext;
import javax.xml.bind.JAXBException;
import javax.xml.bind.Unmarshaller;
import javax.xml.transform.stream.StreamSource;

import org.apache.commons.httpclient.Cookie;
import org.apache.commons.httpclient.HttpClient;
import org.apache.commons.httpclient.NameValuePair;
import org.apache.commons.httpclient.UsernamePasswordCredentials;
import org.apache.commons.httpclient.auth.AuthScope;
import org.apache.commons.httpclient.methods.GetMethod;
import org.familysearch.api.familytree.v1.FamilyTree;
import org.familysearch.api.familytree.v1.NameSummary;
import org.familysearch.api.familytree.v1.SearchHit;
import org.familysearch.api.familytree.v1.SearchPerson;
import org.familysearch.api.familytree.v1.SearchResults;

public class SearchDemo {

	// These strings are used to build the appropriate URLs for logging in and
	// searching
	// We will use this client to perform all our requests
	private static final HttpClient client = new HttpClient();

	// This string will store the session id once we've retrieved it
	private static String SESSION_ID = null;

	// The church throws non-standardized refresh statements at us that this
	// pattern matches
	private final static Pattern REFRESH_PATTERN = Pattern.compile("META\\s+HTTP-EQUIV=\"REFRESH\"\\s+CONTENT=\"(\\d+);URL=([^\"]+)\"", Pattern.CASE_INSENSITIVE);

	/**
	 * This method prompts the user for four parameters if they are not given
	 * and informs the user what the parameters are for.
	 * @param args
	 */
	private static void usage(String[] args) {
		if (args.length < 4) {
			int i = 0;
			out.printf("arg %d, user name%n", ++i);
			out.printf("arg %d, password%n", ++i);
			out.printf("arg %d, given name to search for%n", ++i);
			out.printf("arg %d, family name to search for%n", ++i);
			System.exit(1);
		}
	}

	/**
	 * This search demo authenticates with the family search API, performs a
	 * search request, and unmarshals the response into java objects.
	 * @param args
	 * @throws Throwable
	 */
	public static void main(String[] args) throws Throwable {
		usage(args);
		SESSION_ID = login(args[0], args[1]);
		search(args[2], args[3]);
	}

	/**
	 * The beta family history service currently uses basic authentication and
	 * returns the session id in two different cookies. For whatever reason
	 * these cookies are ignored and the session id must be extracted and resent
	 * as a parameter in subsequent requests.
	 * @param user
	 * @param pass
	 * @throws IOException
	 */
	public static String login(String user, String pass) throws IOException {
		return login(client, user, pass);
	}

	public static String login(HttpClient client, String user, String pass) throws IOException {
		client.getState().setCredentials(new AuthScope(SERVER, PORT), new UsernamePasswordCredentials(user, pass));
		out.printf("authentication to location %s%n", LOGIN_SITE);
		GetMethod request = new GetMethod(LOGIN_SITE);
		request.setDoAuthentication(true);
		int result = client.executeMethod(request);
		String sessionId = verifyAuthentication(client, request, result); // extract
		// the
		// session
		// id
		// from
		// a
		// successful
		// logon
		client.getState().clearCredentials();
		return sessionId;
	}

	/**
	 * A search request is sent by performing a GET request with the correct
	 * parameters to the web page SEARCH_SITE. The response is given in xml and
	 * must be transformed to java objects.
	 * @param givenName
	 * @param familyName
	 * @throws IOException
	 * @throws JAXBException
	 */
	private static void search(String givenName, String familyName) throws IOException, JAXBException {
		assert SESSION_ID != null;
		NameValuePair givenPair = new NameValuePair("givenName", givenName);
		NameValuePair surPair = new NameValuePair("familyName", familyName);
		NameValuePair sessionPair = new NameValuePair("sessionId", SESSION_ID);
		String url = buildUrl(SEARCH_SITE, givenPair, surPair, sessionPair); // attach
		// parameters
		// to
		// the
		// GET
		// request
		GetMethod method = new GetMethod(url);
		int result = client.executeMethod(method);
		handleSearchResult(method, result); // unmarshal and process the
		// response data
	}

	/**
	 * This method verifies that the authentication request was successful, and
	 * if so, extracts the session id for future use.
	 * @param method GetMethod that was executed to authenticate
	 * @param result response code returned by server
	 * @return
	 * @throws IOException
	 */
	private static String verifyAuthentication(HttpClient client, GetMethod method, int result) throws IOException {
		final String responseBody = method.getResponseBodyAsString();
		Cookie[] cookies = client.getState().getCookies();
		/*
		 * 401 error code means authentication was successfully attempted but
		 * failed
		 */
		if (result == 401) {
			err.println(responseBody);
			throw new RuntimeException(format("authentication failed with result %d and numCookies = %d", result, cookies.length));
		}
		/*
		 * The response body often informs the client to redirect to another
		 * site. This if branch handles that case
		 */
		Matcher m = REFRESH_PATTERN.matcher(responseBody);
		if (m.find()) {
			out.println(responseBody);
			out.println("Refreshing");
			method = new GetMethod(m.group(2)); // prepare GET request to
			// redirected site
			return verifyAuthentication(client, method, client.executeMethod(method));
		}
		/*
		 * I'm not sure if this last branch is necessary. The branch above may
		 * handle the situation addressed here, where authentication apparently
		 * succeeds but no cookies were handed back.
		 */
		else if (cookies.length == 0) {
			err.println(responseBody);
			out.println("authentication succeeded but no cookie was given. Retrying.");
			client.executeMethod(method);
		}
		String sessionId = null;
		System.out.println("Cookies: ");
		for (Cookie c : cookies) {
			System.out.printf("\t%s%n", c.toExternalForm());
			if (c.getName().equals("USYSSESSIONID")) {
				sessionId = c.getValue();
			}
		}
		if (sessionId == null) {
			throw new RuntimeException("failed to extract session id from cookies");
		}
		return sessionId;
	}

	public static String buildUrl(String url, NameValuePair... pairs) {
		if (pairs.length == 0)
			return url;
		StringBuilder b = new StringBuilder();
		b.append(url);
		b.append("?");
		for (NameValuePair p : pairs) {
			b.append(p.getName());
			b.append('=');
			b.append(p.getValue());
			b.append('&');
		}
		b.deleteCharAt(b.length() - 1);
		return b.toString();

	}

	/**
	 * This method verifies that the executed search was successful and then
	 * unmarshals and processes the response data
	 * @param method
	 * @param result
	 * @throws IOException
	 * @throws JAXBException
	 */
	private static void handleSearchResult(GetMethod method, int result) throws IOException, JAXBException {
		if (result >= 400) {
			throw new RuntimeException(format("search request failed with response code %d", result));
		}
		InputStream in = method.getResponseBodyAsStream(); // response data
		ByteArrayOutputStream bout = new ByteArrayOutputStream();
		streamDump(in, bout); // dump the response data so we can print it out
		// to standard out if we want to
		streamDump(new ByteArrayInputStream(bout.toByteArray()), System.out); // optionally
		// print
		// the
		// response
		// to
		// standard
		// out

		// this is where the unmarshalling actually occurs
		FamilyTree tree = unmarshal(FamilyTree.class, new ByteArrayInputStream(bout.toByteArray()));
		// Process the results however we want
		SearchResults sr = tree.getSearches();
		for (SearchHit sh : sr.getSearch()) {
			SearchPerson sp = sh.getPerson();
			NameSummary ns = sp.getName();
			String name = ns.getForm().get(0).getFullText();
			int score = sh.getScore();
			String id = sp.getRef();
			out.printf("name: %s%n\tref: %s%n\tscore: %d%n", name, score);
		}
	}

	/**
	 * utility method to dump an input stream into an output stream
	 * @param in
	 * @param out
	 * @throws IOException
	 */
	public static void streamDump(InputStream in, OutputStream out) throws IOException {
		byte[] buffer = new byte[1024];
		int count = 0;
		while ((count = in.read(buffer)) != -1)
			out.write(buffer, 0, count);
	}

	/**
	 * This is the magic unmarshalling method. I do not know the details of how
	 * it works.
	 * @param <T>
	 * @param clazz
	 * @param in
	 * @return
	 * @throws JAXBException
	 */
	public static <T> T unmarshal(Class<T> clazz, InputStream in) throws JAXBException {
		JAXBContext context = JAXBContext.newInstance(clazz.getPackage().getName());
		Unmarshaller um = context.createUnmarshaller();
		return um.unmarshal(new StreamSource(in), clazz).getValue();
	}

}
